Repository navigation
feat(api): pages and widgets on api/2 - #3133
rubenvdlinde wants to merge 2 commits into
Conversation
0511a8e to
d522080
Compare
d522080 to
e97b9ea
Compare
e97b9ea to
daeab7e
Compare
| ['name' => 'ContextPage#create', 'url' => '/api/2/contexts/{contextId}/pages', 'verb' => 'POST'], | ||
| ['name' => 'ContextPage#show', 'url' => '/api/2/contexts/{contextId}/pages/{pageId}', 'verb' => 'GET'], | ||
| ['name' => 'ContextPage#update', 'url' => '/api/2/contexts/{contextId}/pages/{pageId}', 'verb' => 'PATCH'], | ||
| ['name' => 'ContextPage#destroy', 'url' => '/api/2/contexts/{contextId}/pages/{pageId}', 'verb' => 'DELETE'], | ||
| ['name' => 'ContextPage#widgetTypes', 'url' => '/api/2/widget-types', 'verb' => 'GET'], |
There was a problem hiding this comment.
As far as I see, new routes are now added in the controllers, not in the routes.php.
There was a problem hiding this comment.
Thanks @rjzondervan, agreed. The routes are now declared on the controller with #[ApiRoute], and this PR no longer touches appinfo/routes.php.
daeab7e to
a812d77
Compare
a812d77 to
e241b46
Compare
| $nodeType = $source['nodeType'] ?? null; | ||
| $nodeId = $source['nodeId'] ?? null; | ||
| if (!in_array($nodeType, [Application::NODE_TYPE_TABLE, Application::NODE_TYPE_VIEW], true) || !is_int($nodeId) || $nodeId <= 0) { | ||
| throw new BadRequestError('The source of widget ' . $index . ' needs a nodeRelId of this application, or a nodeType and nodeId of a table or view.'); | ||
| } | ||
| $key = $this->nodeKey($nodeType, $nodeId); | ||
| if (isset($relIdsByNodeKey[$key])) { | ||
| return [$relIdsByNodeKey[$key], null]; | ||
| } | ||
| return [null, ['nodeType' => $nodeType, 'nodeId' => $nodeId]]; |
There was a problem hiding this comment.
Do we agree that a data widget can only show a table or view that is already in the application? This seems to ignore that, and lets a page save add new tables to the application, which also gives every sharee of the application read access to them. The application owner also gets full rights to any table in the application 🤔
There was a problem hiding this comment.
Yes, we agree. Saving a page no longer adds anything to the application. A data widget's source has to be {nodeRelId} of a table or view that's already in it; anything else is a 400, with the same message whether the table doesn't exist or just isn't in the application. The {nodeType, nodeId} form is gone, with its tests.
| private function getManageableContext(int $contextId, string $userId): Context { | ||
| $context = $this->contextMapper->findById($contextId, $userId); | ||
| if (!$this->permissionsService->canManageContextById($contextId, $userId)) { | ||
| throw new PermissionError(sprintf('User %s cannot manage application %d', $userId, $contextId)); | ||
| } | ||
| return $context; |
There was a problem hiding this comment.
Could we check the permission with canManageContext($context, $userId), using the application we already loaded here?
There was a problem hiding this comment.
so we don't have to find it first. Seems like that should work
There was a problem hiding this comment.
Done, it uses canManageContext() with the application loaded just before.
There was a problem hiding this comment.
We have the checks on the Controller layer, which should be sufficient. They can be dropped here to save some CPU cycles and DB queries, assuming that behind the external guards, internal handling is controlled and safe.
| * 403: No permissions | ||
| * 404: Not found | ||
| * | ||
| * @CanManageContext |
There was a problem hiding this comment.
Doesn't #[RequirePermission] already does the same check.
There was a problem hiding this comment.
It does. I removed the @CanManageContext annotation here and on destroy().
| * 403: No permissions on the application or on a table or view to add | ||
| * 404: Not found | ||
| * | ||
| * @CanManageContext |
There was a problem hiding this comment.
Doesn't #[RequirePermission] already does the same check?
| * [api v2] Change a page or its widgets | ||
| * | ||
| * Fields left out keep their value; an empty icon or description removes it. A new | ||
| * technical name must be valid and free in the application. When | ||
| * `widgets` is given, the widgets of the grid page are made to match it in one | ||
| * transaction: listed widgets with an `id` are updated, listed widgets without one | ||
| * are added and widgets that are not listed are deleted. | ||
| * | ||
| * A widget has a `type`, an optional `title`, `icon` and `description`, `gridX`, `gridY`, `gridWidth` and | ||
| * `gridHeight` on a grid of 12 columns and `settings` following the schema of its | ||
| * type. A widget of a type with a source has a `source`: `{nodeRelId}` of a table or | ||
| * view of this application, `{nodeType, nodeId}` of a table or view the user manages, | ||
| * which is added to the application, or null. A table or view added this way is also | ||
| * placed on the start page, and both stay when the widget is removed later. | ||
| * | ||
| * A page holds at most 100 widgets. When another save changed the page in between, | ||
| * for instance removed a listed widget, the save is refused and can be retried on the | ||
| * page as it is now. |
There was a problem hiding this comment.
Too long, which adds clutter. The code should be readable enough to not need long comments. Also, it's very easy for comments that reference code properties to become outdated and just confusing
There was a problem hiding this comment.
Agreed. It's down to what the code can't tell an API user: what a widgets list does to the page, and what a widget looks like. I shortened the comments in the other files I touched as well.
| * 200: Page returned | ||
| * 404: Not found |
There was a problem hiding this comment.
That's on purpose. Reading a page only needs read access, which everyone in the application has. Someone outside it gets a 404 from the permission middleware, like on the other application endpoints, so outsiders can't tell whether an application exists. I added that to the 404 description, so the API docs say it too.
There was a problem hiding this comment.
Similar comment as at https://github.com/nextcloud/tables/pull/3131/changes#r4431715470 about all these tests
There was a problem hiding this comment.
Done here as well: 44 test methods down to 25, and 677 test lines down to 544. The controller tests are now one data provider for the endpoints and one for the errors; the checks on route and rate limit attributes are gone.
e241b46 to
ba00142
Compare
ba00142 to
d87850f
Compare
Read, add, change and delete grid pages with their widgets, and list the widget types with their settings schemas. Reading needs read access to the application, changes need manage access. Page creation is limited to 20 a minute per user. Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: Ruben van der Linde <ruben@conduction.nl>
Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: Ruben van der Linde <ruben@conduction.nl>
d87850f to
25ed0cc
Compare
|
Hello there, We hope that the review process is going smooth and is helpful for you. We want to ensure your pull request is reviewed to your satisfaction. If you have a moment, our community management team would very much appreciate your feedback on your experience with this PR review process. Your feedback is valuable to us as we continuously strive to improve our community developer experience. Please take a moment to complete our short survey by clicking on the following link: https://cloud.nextcloud.com/apps/forms/s/i9Ago4EQRZ7TWxjfmeEpPkf6 Thank you for contributing to Nextcloud and we hope to hear from you soon! (If you believe you should not receive this message, you can add yourself to the blocklist.) |
blizzz
left a comment
There was a problem hiding this comment.
Blocking is the JSON Type. Also, message must be made translatable, this can be a follow up though.
| */ | ||
| public function setSettingsArray(array $settings): void { | ||
| $this->setSettings(json_encode((object)$settings, JSON_THROW_ON_ERROR)); | ||
| $this->addType('settings', Types::JSON); |
| class PageService { | ||
| use TTransactional; | ||
|
|
||
| private const CHANGED_IN_BETWEEN = 'The page was changed by another save in the meantime. Load it again and retry.'; |
There was a problem hiding this comment.
This is not translatable, but sounds (end) user facing.
| private function getManageableContext(int $contextId, string $userId): Context { | ||
| $context = $this->contextMapper->findById($contextId, $userId); | ||
| if (!$this->permissionsService->canManageContextById($contextId, $userId)) { | ||
| throw new PermissionError(sprintf('User %s cannot manage application %d', $userId, $contextId)); | ||
| } | ||
| return $context; |
There was a problem hiding this comment.
We have the checks on the Controller layer, which should be sufficient. They can be dropped here to save some CPU cycles and DB queries, assuming that behind the external guards, internal handling is controlled and safe.
| try { | ||
| $save(); | ||
| } catch (\InvalidArgumentException $e) { | ||
| throw new BadRequestError(self::CHANGED_IN_BETWEEN, 0, $e); |
| if ($newTechnicalName !== null) { | ||
| throw new BadRequestError('The technical name ' . $newTechnicalName . ' is already used by a page of this application.', 0, $e); | ||
| } | ||
| throw new BadRequestError(self::CHANGED_IN_BETWEEN, 0, $e); |
|
|
||
| $id = $widget['id'] ?? null; | ||
| if ($id !== null && (!is_int($id) || !isset($existingIds[$id]) || isset($seenIds[$id]))) { | ||
| throw new BadRequestError('Widget ' . $index . ' has the id of no other widget on this page.'); |
There was a problem hiding this comment.
Is the index (here and elsewhere) to the end user? Also, all user facing messages have to be translateable.
AndyScherzinger
left a comment
There was a problem hiding this comment.
Review focused on performance, accessibility, security, clean code and architecture. The inline comments only cover points that aren't in the existing threads here, on #3132 or on #3131; where one follows from an earlier thread, it links to it. This PR has no UI of its own, so the accessibility points are about what the API accepts and how the page bar in #3215 shows it. Points marked as reproduced or simulated were checked on a dev instance (MariaDB) running this branch.
| if (count($widgets) > GridWidgetTypes::MAX_WIDGETS_PER_PAGE) { | ||
| throw new BadRequestError('A page holds at most ' . GridWidgetTypes::MAX_WIDGETS_PER_PAGE . ' widgets.'); | ||
| } | ||
| $existingIds = array_flip($this->widgetService->findIdsByPageId($pageId)); |
There was a problem hiding this comment.
A widgets list replaces all widgets of the page, and the only conflict this detects is a change that lands while the request runs. A save sent from a page loaded before another save went through is taken as it is: widgets the other save added are deleted and its changes to the listed widgets are overwritten, with a 200. In #3215 GridPage.vue copies the page into a draft when editing starts and sends every widget of the draft on save, so two people editing the same page (or one person in two tabs) undo each other's work without a message, and the window is the whole edit session.
Reproduced on the dev instance: a save that added a header returned 200; a second save built from the page before it, changing another widget's text, also returned 200, and the new header was gone. The one stale case that is caught, a listed widget the other save removed, is answered with "Widget 0 has the id of no other widget on this page." from the check below instead of the "Load it again and retry" message CHANGED_IN_BETWEEN has for it.
For saves that do overlap, saveChanges() decides by what the request asked for rather than by what failed: a request that renames the page and collides with another save on a widget's source key is told its new technical name is taken (only an API client sends both in one request), and any other database error passes through as a 500. Two saves that update the same widgets in a different order can deadlock, and so can two in the same order when one of them drops a widget the other keeps. Simulated on the dev instance with each save holding its first widget's row lock for a moment: one of the two failed in every run, and not even with the deadlock. MariaDB rolls back the victim's whole transaction, so rolling back to the savepoint of syncPage()'s nested atomic() fails with "SAVEPOINT DOCTRINE_2 does not exist", which replaces the original error and ends as a 500.
A version column on the page (the migration from #3131 isn't released yet), returned in TablesPage and sent back with the save, closes this: the transaction starts with UPDATE ... SET version = version + 1 WHERE id = ? AND version = ?, and 0 affected rows is the "load it again" answer (a 409, as blizzz suggests for saveChanges()). That one statement covers added, changed and removed widgets, serialises two saves of the same page through its row lock, and leaves saveChanges() only the renamed technical name to map.
| #[NoAdminRequired] | ||
| #[ApiRoute(verb: 'GET', url: '/api/2/widget-types')] | ||
| public function widgetTypes(): DataResponse { | ||
| return new DataResponse(array_values($this->gridWidgetTypes->all())); |
There was a problem hiding this comment.
The data type has no settings, and json_encode() writes its empty settings array as [], while TablesWidgetType and the generated spec declare an object (additionalProperties: WidgetSettingSchema). On the dev instance GET /api/2/widget-types returns "settings":[] for data and objects for the other two types, so a client generated from openapi.json that maps this to a dictionary (Kotlin, Swift, Dart) can't read the list, and a response validator flags it. The Vue code in the stack only gets by because Object.entries([]) is empty.
Widget::getSettingsObject() and the |\stdClass on TablesWidget.settings already handle this for a widget's own settings. Returning new \stdClass() here only when a type has no settings (typed array<string, TablesWidgetSettingSchema>|\stdClass), and asserting {} for data in testWidgetTypesListsEveryType(), fixes it; casting every schema to an object would lose the non-empty ones in OCS XML (see the comment on Widget::getSettingsObject()).
| * @param string|null $technicalName New technical name | ||
| * @param string|null $icon New icon name, empty to remove it | ||
| * @param string|null $description New description, empty to remove it | ||
| * @param list<array<string, mixed>>|null $widgets All widgets of the page |
There was a problem hiding this comment.
The generator turns list<array<string, mixed>> into additionalProperties: {type: object}: openapi.json says every field of a widget is an object, and openapi.ts types each one as Record<string, never>, so a client built from the spec can't send type: 'header' or gridX: 0 without a type error, and the widget shape (which fields, which are required, source: {nodeRelId}) exists only in the prose above.
The generator handles array shapes in parameters ($nodes of ContextController::importScheme() comes out with its fields and required list), so a shape such as list<array{id?: ?int, type: string, title?: ?string, icon?: ?string, description?: ?string, gridX: int, gridY: int, gridWidth: int, gridHeight: int, settings?: ?array<string, TablesWidgetValue>, source?: ?array{nodeRelId: int}}>, best as a named type in ResponseDefinitions (the internal TablesWidgetInput in PageService describes the parsed form), documents what the endpoint accepts. It would also be the natural input type for the value object discussed in #3132 (comment).
| #[NoAdminRequired] | ||
| #[RequirePermission(permission: Application::PERMISSION_MANAGE, typeParam: 'context', idParam: 'contextId')] | ||
| #[ApiRoute(verb: 'DELETE', url: '/api/2/contexts/{contextId}/pages/{pageId}', requirements: ['contextId' => '\d+', 'pageId' => '\d+'])] |
There was a problem hiding this comment.
AGENTS.md asks for #[UserRateLimit(limit: 20, period: 60)] on mutation endpoints. create() and update() carry one, destroy() doesn't: on the dev instance 25 DELETEs in 16 seconds all returned 200, while the 21st POST within a minute got a 429.
| * @throws BadRequestError | ||
| */ | ||
| private function validateTitle(string $title): string { | ||
| $title = trim($title); |
There was a problem hiding this comment.
Same gap as #3132 (comment), on the path users take most: on the dev instance POST .../pages with {"title": "\u00a0"} or {"title": "\u200b"} returns 200 and stores that title. In #3215 a page tab gets its name only from the title (pageTitle() returns page.title when it is truthy, the icon is aria-hidden), so such a page shows an empty tab that screen readers announce without a name, which the 1 to 200 characters rule here is meant to prevent.
OCA\Tables\Service\ValueObject\Title already refuses the \u00a0 case for tables, views and columns (ColumnService::normalizeTitle() wraps it; \u200b needs the addition mentioned there), so the shared helper suggested there would cover validateTitle() and validateDescription() here as well. Title counts bytes (strlen() > 200) while this counts characters, so reusing it as it is would shorten the limit for non-Latin titles.
| if ($pageArray['page_type'] !== Page::TYPE_GRID) { | ||
| throw new BadRequestError('Only grid pages hold widgets.'); | ||
| } |
There was a problem hiding this comment.
#3211, stacked on this PR, removes this check again ("The page endpoint accepts widgets on the start page, which still cannot be deleted.") and turns testTheStartPageHoldsNoWidgets() into testTheStartPageHoldsWidgets(). So this 400 only exists between the two PRs, and if this one ships first, API clients see the rule flip between releases. Leaving it out here keeps the contract stable. The delete rule on line 172 then reads better as === Page::TYPE_STARTPAGE, which is what it means; it would also let a page of another type, which imports before #3131 could create, be deleted instead of being refused as the start page.
It also makes the suggestion on #3132 to skip start-page widgets in importWidget() (#3132 (comment)) moot: with #3211 the importer already does what the stack ends up with.
| * @throws BadRequestError | ||
| * @throws Exception | ||
| */ | ||
| private function saveNamed(callable $save, string $technicalName): void { |
There was a problem hiding this comment.
saveNamed() is saveChanges() with a technical name, minus the \InvalidArgumentException branch that an insert can't reach, so $this->saveChanges(fn () => $this->pageMapper->insert($page), $technicalName) in create() behaves the same and this method can go. The copy has already drifted: it drops the unique violation as previous exception, which saveChanges() keeps for the log. The "already used" message also exists three times (lines 303, 323, 339), and the translation blizzz asked for would have to cover each copy.
| if ($title !== null) { | ||
| $page->setTitle($this->validateTitle($title)); | ||
| } |
There was a problem hiding this comment.
A start page gets a title here but can't lose it again: it starts as NULL (ContextService::insertPage()), {"title": "Start"} is accepted, and {"title": ""} is refused by validateTitle(), while an empty icon or description removes the value. The page bar in #3215 shows the translated "Home" only while the title is NULL, so once any client sets one, every user sees that string, in one language, for good. Reproduced on the dev instance (200, then 400). Letting '' reset the start page's title to NULL, or refusing a title for it, keeps the change reversible.
| */ | ||
| public function getSettingsObject(): \stdClass { | ||
| $settings = $this->getSettingsArray(); | ||
| $settings = $this->getSettings() ?? []; |
There was a problem hiding this comment.
The removed getSettingsArray() turned a stored value that isn't a JSON object into {}, and the deleted WidgetTest case "not an object" covered that. With Types::JSON and a ?array property, Nextcloud's Entity setter assigns whatever json_decode() returns, so a stored scalar such as "text" now throws a TypeError while the row is hydrated (broken JSON still decodes to null and is fine). The app doesn't write such values itself, but a manual fix, a restored dump or a later migration can, and then the page can't be read, saved or deleted through this API (the controller doesn't catch \Error), and the exports that read its widgets fail too.
Reproduced on the dev instance by setting one widget's settings to "text": GET and DELETE .../pages/{id} answered 500 with "Cannot assign string to property OCA\Tables\Db\Widget::$settings of type ?array", and so did the application's scheme export.
Whatever replaces the JSON type after blizzz's comment above, an is_array() guard where the stored value is decoded keeps one bad row from locking the whole page.
| $settings = $this->getSettingsArray(); | ||
| $settings = $this->getSettings() ?? []; | ||
| ksort($settings); | ||
| return (object)$settings; |
There was a problem hiding this comment.
The page endpoints return every widget's settings as a \stdClass, and Nextcloud's OCS XML renderer turns any \stdClass into an empty element (BaseResponse::toXML(): if ($v instanceof \stdClass) { $v = []; }). XML is what OCS answers when a client sends neither format=json nor Accept: application/json, so in that format GET, PATCH and DELETE .../pages/{pageId} return <settings/> for every widget and its heading, text and colours are gone. On the dev instance the same page gives {"text": "Edited by A"} for a text widget in JSON and <settings/> in XML.
Returning the sorted array when it has entries and new \stdClass() only when it is empty keeps the JSON output as it is (TablesWidget.settings already allows both) and keeps the values in XML.
Part of #3099, on top of #3132 (widget lifecycle). This adds the five endpoints from #3100. They're routed with
#[ApiRoute]on the controller, like the newer public endpoints, soroutes.phpdoesn't change.Size: 3562 lines: 729 code, 544 tests, and 2289 generated OpenAPI (
openapi.jsonandopenapi.ts) in a separate commit you can skip.GET /api/2/contexts/{contextId}/pages/{pageId}POST /api/2/contexts/{contextId}/pagesPATCH /api/2/contexts/{contextId}/pages/{pageId}DELETE /api/2/contexts/{contextId}/pages/{pageId}GET /api/2/widget-typessourceis{nodeRelId}of that application. Anything else is refused with a 400, with the same message whether the table doesn't exist or just isn't in the application.page_<id>. An invalid one, or one already used in the application, gives a 400.OpenAPI and the TypeScript types are regenerated, in a separate commit.
Saving a page never changes what's in the application, so it can't give the application's sharees access to another table. To show a table that isn't in the application yet, add it to the application first.
How it was tested
On Nextcloud 34 with PostgreSQL:
PageServiceTest, including that someone outside the application gets a 404 before any write, and that a source outside the application is refusedContextPageControllerTest: every endpoint, and 400, 403 and 404WidgetServiceTestandGridWidgetTypesTestcomposer cs:check, psalm and rector, all cleanBy hand on a test instance:
🖼️ Screenshots
Backend only, no UI changes. The frontend follows in separate pull requests.
🏁 Checklist
/backport to stableX.X🤖 AI (if applicable)